Rare Case of Privilege Escalation Patched in LiteSpeed Cache Plugin

Published 29 October 2024
Rafie Muhammad
Security Researcher at Patchstack
Table of Contents

LiteSpeed Cache

Unauthenticated Privilege Escalation

6+ million
CVSS 8.1

The vulnerability in the LiteSpeed Cache plugin was originally reported by Patchstack Alliance community member TaiYou to the Patchstack bug bounty program for WordPress. We are collaborating with the researcher to release the content of this security advisory article.

This blog post is about the LiteSpeed plugin vulnerability. If you’re a LiteSpeed user, please update the plugin to at least version 6.5.2.

All Patchstack users that have protection enabled have been automatically protected from this vulnerability. Sign up for the free Community account first, to scan for vulnerabilities and apply protection for only $5 / site per month with Patchstack.

For plugin developers, we have security audit services and Enterprise API for hosting companies.

About the LiteSpeed Cache Plugin

The plugin LiteSpeed Cache (free version), which has over 6 million active installations, is known as the most popular caching plugin in WordPress.

This WordPress plugin is an all-in-one site acceleration plugin, featuring an exclusive server-level cache and a collection of optimization features. The plugin supports WordPress Multisite and is compatible with the most popular plugins, including WooCommerce, bbPress, and Yoast SEO.

The security vulnerability

The plugin suffers from an unauthenticated privilege escalation vulnerability which allows any unauthenticated visitor to gain Administrator level access after which malicious plugins could be uploaded and installed.

The vulnerability exploits a user simulation feature in the plugin which is a similar case previously reported by John Blackbourn in this article. This vulnerability utilizes a weak security hash check that uses known values and can only be reproducible with some additional rare configuration that needs to be performed by the Administrator role user in the plugin’s Crawler feature. The vulnerability has been assigned CVE-2024-50550 and was fixed in version 6.5.2 of the plugin.

Unauthenticated Privilege Escalation

The underlying issue is located in the is_role_simulation function:

public function is_role_simulation()
{
	if (is_admin()) {
		return;
	}

	if (empty($_COOKIE['litespeed_hash']) && empty($_COOKIE['litespeed_flash_hash'])) {
		return;
	}

	self::debug('starting role validation');

	// Check if is from crawler
	// if ( empty( $_SERVER[ 'HTTP_USER_AGENT' ] ) || strpos( $_SERVER[ 'HTTP_USER_AGENT' ], Crawler::FAST_USER_AGENT ) !== 0 ) {
	// 	Debug2::debug( '[Router] user agent not match' );
	// 	return;
	// }

	// Flash hash validation
	if (!empty($_COOKIE['litespeed_flash_hash'])) {
		$hash_data = self::get_option(self::ITEM_FLASH_HASH, array());
		if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts']) && !empty($hash_data['uid'])) {
			if (time() - $hash_data['ts'] < 120 && $_COOKIE['litespeed_flash_hash'] == $hash_data['hash']) {
				self::debug('role simulate uid ' . $hash_data['uid']);
				self::delete_option(self::ITEM_FLASH_HASH);
				wp_set_current_user($hash_data['uid']);
				return;
			}
		}
	}
	// Hash validation
	if (!empty($_COOKIE['litespeed_hash'])) {
		$hash_data = self::get_option(self::ITEM_HASH, array());
		if ($hash_data && is_array($hash_data) && !empty($hash_data['hash']) && !empty($hash_data['ts']) && !empty($hash_data['uid'])) {
			if (time() - $hash_data['ts'] < $this->conf(Base::O_CRAWLER_RUN_DURATION) && $_COOKIE['litespeed_hash'] == $hash_data['hash']) {
				if (empty($hash_data['ip'])) {
					$hash_data['ip'] = self::get_ip();
					self::update_option(self::ITEM_HASH, $hash_data);
				} else {
					$server_ips = apply_filters('litespeed_server_ips', array($hash_data['ip']));
					if (!self::ip_access($server_ips)) {
						self::debug('WARNING: role simulator ip check failed [db ip] ' . $hash_data['ip'], $server_ips);
						return;
					}
				}
				wp_set_current_user($hash_data['uid']);
				return;
			}
		}
	}

	self::debug('WARNING: role simulator hash not match');
}

As explained in the previous article regarding the privilege escalation case, a role simulation feature is used on the crawler feature in the LiteSpeed Cache plugin. If we look at the function closely, the first check is for the Flash Hash check, which only will be accepted if the hash time generation is no older than 120 seconds. This check should be enough to prevent a mass brute-forcing of the hash.

However, the second check on $_COOKIE[‘litespeed_hash’] will check the TTL of the hash based on the $this->conf(Base::O_CRAWLER_RUN_DURATION). This configuration value can be viewed on the Crawler settings and has a default value of 400 seconds. This value can be configured by the administrator to a certain high but realistic value such as 2500-4000 seconds and it will make the mentioned vulnerability reproducible.

As mentioned in the patch section of the previous article, the used hash now indeed will have a length of 32 characters. However, the rrand() function is still using mt_srand((int) ((float) microtime() * 1000000)) before the mt_rand() call, thus making the generated hash value limited to 1 million possibilities despite it having 32 random characters.

This vulnerability also needs the $this->conf(Base::O_CRAWLER_LOAD_LIMIT) to have a value of 0. This is because, if the value is set to 0, then the crawler process will be stopped right away when the crawl process is started. Let’s see these two functions below, which are _engine_start and _adjust_current_threads:

private function _engine_start()
{
    // check if is running
    // if ($this->_summary['is_running'] && time() - $this->_summary['is_running'] < $this->_crawler_conf['run_duration']) {
    //  $this->_end_reason = 'stopped';
    //  self::debug('The crawler is running.');
    //  return;
    // }

    // check current load
    $this->_adjust_current_threads();
    if ($this->_cur_threads == 0) {
        $this->_end_reason = 'stopped_highload';
        self::debug('Stopped due to heavy load.');
        return;
    }
------------ CUT HERE ------------
private function _adjust_current_threads()
{
	$curload = $this->get_server_load();
	if ($curload == -1) {
		self::debug('set threads=0 due to func sys_getloadavg not exist!');
		$this->_cur_threads = 0;
		return;
	}

	$curload /= $this->_ncpu;
	// $curload = 1;

	if ($this->_cur_threads == -1) {
		// init
		if ($curload > $this->_crawler_conf['load_limit']) {
			$curthreads = 0;
		} elseif ($curload >= $this->_crawler_conf['load_limit'] - 1) {
			$curthreads = 1;
		} else {
			$curthreads = intval($this->_crawler_conf['load_limit'] - $curload);
			if ($curthreads > $this->conf(Base::O_CRAWLER_THREADS)) {
				$curthreads = $this->conf(Base::O_CRAWLER_THREADS);
			}
		}
	} else {
		// adjust
		$curthreads = $this->_cur_threads;
		if ($curload >= $this->_crawler_conf['load_limit'] + 1) {
			sleep(5); // sleep 5 secs
			if ($curthreads >= 1) {
				$curthreads--;
			}
		} elseif ($curload >= $this->_crawler_conf['load_limit']) {
			// if ( $curthreads > 1 ) {// if already 1, keep
			$curthreads--;
			// }
		} elseif ($curload + 1 < $this->_crawler_conf['load_limit']) {
			if ($curthreads < $this->conf(Base::O_CRAWLER_THREADS)) {
				$curthreads++;
			}
		}
	}

	// $log = 'set current threads = ' . $curthreads . ' previous=' . $this->_cur_threads
	// 	. ' max_allowed=' . $this->conf( Base::O_CRAWLER_THREADS ) . ' load_limit=' . $this->_crawler_conf[ 'load_limit' ] . ' current_load=' . $curload;

	$this->_cur_threads = $curthreads;
	$this->_cur_thread_time = time();
}

So, the crawler process when executed will call the _engine_start() function, then the function will call the _adjust_current_threads() function. The initial value of $this->_cur_threads itself is -1, so when the process is started on the _adjust_current_threads() function and we set the $this->_crawler_conf[‘load_limit’] value to 0, then it will set the $curthreads to 0 value and eventually assign it to $this->_cur_threads variable. Back to the _engine_start() function, if the $this->_cur_threads value is set to 0, then the initial crawl process will be stopped.

Back to the is_role_simulation() function, since there is no hit yet to the role simulation from the initial crawl process, the $hash_data value does not contain the crawler IP, so when the attacker tries to perform a brute force on the hash and successfully done so, it will bypass the check of crawler IP (!self::ip_access($server_ips)).

This vulnerability is realistically reproducible with this plugin configuration:

  • Crawler->General Settings->Crawler: ON
  • Crawler->General Settings->Run Duration: 2500 – 4000
  • Crawler->General Settings->Interval Between Runs: 2500 – 4000
  • Crawler->General Settings->Server Load Limit: 0
  • Crawler->Simulation Settings->Role Simulation: 1 (ID of user with Administrator role)
  • Crawler->Summary->Activate: Turn every row to OFF except Administrator

The patch

Since this vulnerability exists because the code uses a weak hash check and weak implementation of the role simulation process, the LiteSpeed team decided to apply these additional protections:

  • The role simulation process is removed.
  • Usage of mt_srand on rrand function is removed to not limit the possibility of hash generated value to 1 million possible hashes.

The full patch can be seen in this changeset.

A little note on the patch, we recommend using a more secure random value generator such as the random_bytes function. This was not implemented due to the need for legacy PHP support. However, according to the LiteSpeed team, the usage of the random_bytes function will be implemented in the future version of the plugin.

Conclusion

This vulnerability highlights the critical importance of ensuring the strength and unpredictability of values that are used as security hashes or nonces. The rand() and mt_rand() functions in PHP return values that may be “random enough” for many use cases, but they are not unpredictable enough to be used in security-related features, especially if mt_srand is used in a limited possibility.

Any feature regarding role simulation or other user simulation should also be protected with proper access control.

Want to learn more about finding and fixing vulnerabilities?

Explore our Academy to master the art of finding and patching vulnerabilities within the WordPress ecosystem. Dive deep into detailed guides on various vulnerability types, from discovery tactics for researchers to robust fixes for developers. Join us and contribute to our growing knowledge base.

Timeline

23 September, 2024We received the report from our alliance member TaiYou and initially rejected the report due to incomplete PoC and unrealistic scenario used.
24 September, 2024We reached out to the LiteSpeed team regarding the potential issue and suggested performing a security enhancement.
05-10 October, 2024The researcher reached out to us to improve the PoC used and give more details on the realistic requirements needed to exploit the issue. We informed the LiteSpeed team that the previous issue sent is a valid vulnerability issue.
17 October, 2024LiteSpeed Cache version 6.5.2 was released to patch the reported issues.
29 October, 2024Added the vulnerabilities to the Patchstack vulnerability database. Security advisory article publicly released.

Help us make the Internet a safer place

Making the WordPress ecosystem more secure is a team effort, and we believe that plugin developers and security researchers should work together.

  • If you’re a plugin developer, join our mVDP program that makes it easier to report, manage and address vulnerabilities in your software.
  • If you’re a security researcher, join Patchstack Alliance to report vulnerabilities & earn rewards.

The latest in Security advisories

Looks like your browser is blocking our support chat widget. Turn off adblockers and reload the page.
crossmenu